Micron Document
🎖️GitЯра🎖️


Displaying Raw • Download

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt 41414f832f938b655c97bc07509bc140ea16e2a3 (41414f83) Text, 41.23 KB

T8b949e/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Tf0883e@fileTb4b4b4:Te6edf3SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooManyFunctionsTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffMagicNumberTa5d6ff"Tb4b4b4)

Tff7b72package T7ee787org.meshtastic.feature.discovery

Tff7b72import T7ee787co.touchlab.kermit.Logger
Tff7b72import T7ee787kotlinx.coroutines.CancellationException
Tff7b72import T7ee787kotlinx.coroutines.CoroutineScope
Tff7b72import T7ee787kotlinx.coroutines.Job
Tff7b72import T7ee787kotlinx.coroutines.SupervisorJob
Tff7b72import T7ee787kotlinx.coroutines.cancel
Tff7b72import T7ee787kotlinx.coroutines.currentCoroutineContext
Tff7b72import T7ee787kotlinx.coroutines.delay
Tff7b72import T7ee787kotlinx.coroutines.ensureActive
Tff7b72import T7ee787kotlinx.coroutines.flow.MutableStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.StateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.asStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.first
Tff7b72import T7ee787kotlinx.coroutines.launch
Tff7b72import T7ee787kotlinx.coroutines.sync.Mutex
Tff7b72import T7ee787kotlinx.coroutines.sync.withLock
Tff7b72import T7ee787kotlinx.coroutines.withTimeoutOrNull
Tff7b72import T7ee787org.koin.core.annotation.Single
Tff7b72import T7ee787org.meshtastic.core.common.di.ApplicationCoroutineScope
Tff7b72import T7ee787org.meshtastic.core.common.util.latLongToMeter
Tff7b72import T7ee787org.meshtastic.core.common.util.nowMillis
Tff7b72import T7ee787org.meshtastic.core.database.dao.DiscoveryDao
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoveredNodeEntity
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoverySessionEntity
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoverySessionStatus
Tff7b72import T7ee787org.meshtastic.core.di.CoroutineDispatchers
Tff7b72import T7ee787org.meshtastic.core.model.ChannelOption
Tff7b72import T7ee787org.meshtastic.core.model.ConnectionState
Tff7b72import T7ee787org.meshtastic.core.model.DataPacket
Tff7b72import T7ee787org.meshtastic.core.model.util.decodeOrNull
Tff7b72import T7ee787org.meshtastic.core.model.util.snrOrNull
Tff7b72import T7ee787org.meshtastic.core.repository.DiscoveryPacketCollector
Tff7b72import T7ee787org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
Tff7b72import T7ee787org.meshtastic.core.repository.MeshPrefs
Tff7b72import T7ee787org.meshtastic.core.repository.NodeRepository
Tff7b72import T7ee787org.meshtastic.core.repository.RadioConfigRepository
Tff7b72import T7ee787org.meshtastic.core.repository.RadioController
Tff7b72import T7ee787org.meshtastic.core.repository.ServiceRepository
Tff7b72import T7ee787org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
Tff7b72import T7ee787org.meshtastic.proto.Channel
Tff7b72import T7ee787org.meshtastic.proto.ChannelSettings
Tff7b72import T7ee787org.meshtastic.proto.Config
Tff7b72import T7ee787org.meshtastic.proto.MeshPacket
Tff7b72import T7ee787org.meshtastic.proto.NeighborInfo
Tff7b72import T7ee787org.meshtastic.proto.PortNum
Tff7b72import T7ee787org.meshtastic.proto.Position
Tff7b72import T7ee787org.meshtastic.proto.Telemetry

T8b949e/**
* Core scan engine for Local Mesh Discovery.
*
* Cycles through a queue of LoRa presets, dwells on each for a configured duration while collecting packets, then
* persists aggregated results via [DiscoveryDao].
*/
Tf0883e@Single
Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffLargeClassTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffLongParameterListTa5d6ff"Tb4b4b4)
Tff7b72class T56d364DiscoveryScanEngineTb4b4b4(
Tff7b72private Tff7b72val Te6edf3radioControllerTb4b4b4: Te6edf3RadioControllerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3serviceRepositoryTb4b4b4: Te6edf3ServiceRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3nodeRepositoryTb4b4b4: Te6edf3NodeRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3radioConfigRepositoryTb4b4b4: Te6edf3RadioConfigRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3collectorRegistryTb4b4b4: Te6edf3DiscoveryPacketCollectorRegistryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3discoveryDaoTb4b4b4: Te6edf3DiscoveryDaoTb4b4b4,
Tff7b72private Tff7b72val Te6edf3aiProviderTb4b4b4: Te6edf3DiscoverySummaryAiProviderTb4b4b4,
Tff7b72private Tff7b72val Te6edf3applicationScopeTb4b4b4: Te6edf3ApplicationCoroutineScopeTb4b4b4,
Tff7b72private Tff7b72val Te6edf3dispatchersTb4b4b4: Te6edf3CoroutineDispatchersTb4b4b4,
Tff7b72private Tff7b72val Te6edf3meshPrefsTb4b4b4: Te6edf3MeshPrefsTb4b4b4,
Tb4b4b4) Tb4b4b4: Te6edf3DiscoveryPacketCollector Tb4b4b4{

T8b949e// region Public state

Tff7b72private Tff7b72val Te6edf3_scanState Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3DiscoveryScanStateTff7b72>Tb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3IdleTb4b4b4)
Tff7b72val Te6edf3scanStateTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3DiscoveryScanStateTff7b72> Tff7b72= Te6edf3_scanStateTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3_currentSession Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3DiscoverySessionEntity?Tff7b72>Tb4b4b4(Tff7b72nullTb4b4b4)
Tff7b72val Te6edf3currentSessionTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3DiscoverySessionEntity?Tff7b72> Tff7b72= Te6edf3_currentSessionTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72override Tff7b72val Te6edf3isActiveTb4b4b4: Tffa657Boolean
Tff7b72getTb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Idle Tff7b72&Tff7b72&
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Complete Tff7b72&Tff7b72&
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Failed

T8b949e// endregion

T8b949e// region Internal scan state

Tff7b72private Tff7b72val Te6edf3mutex Tff7b72= Te6edf3MutexTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3homeRestorer Tff7b72=
Te6edf3DiscoveryHomeRestorerTb4b4b4(Te6edf3radioControllerTb4b4b4, Te6edf3serviceRepositoryTb4b4b4, Te6edf3discoveryDaoTb4b4b4, Te6edf3applicationScopeTb4b4b4, Te6edf3meshPrefsTb4b4b4)
Tff7b72private Tff7b72val Te6edf3terminalCoordinator Tff7b72=
Te6edf3DiscoveryTerminalCoordinatorTb4b4b4(
Te6edf3discoveryDao Tff7b72= Te6edf3discoveryDaoTb4b4b4,
Te6edf3homeRestorer Tff7b72= Te6edf3homeRestorerTb4b4b4,
Te6edf3applicationScope Tff7b72= Te6edf3applicationScopeTb4b4b4,
Te6edf3onSessionUpdated Tff7b72= Tb4b4b4{ Te6edf3updated Tff7b72-Tff7b72> Te6edf3_currentSessionTb4b4b4.Te6edf3value Tff7b72= Te6edf3updated Tb4b4b4}Tb4b4b4,
Te6edf3onTerminalCompleted Tff7b72= Tb4b4b4{ Te6edf3outcome Tff7b72-Tff7b72> Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompleteTb4b4b4(Te6edf3outcomeTb4b4b4) Tb4b4b4}Tb4b4b4,
Te6edf3cancelScan Tff7b72= Tb4b4b4{ Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{ Te6edf3cancelScanInternalTb4b4b4(Tb4b4b4) Tb4b4b4} Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3interruptedSessionRecovery Tff7b72=
Te6edf3DiscoveryInterruptedSessionRecoveryTb4b4b4(
Te6edf3serviceRepository Tff7b72= Te6edf3serviceRepositoryTb4b4b4,
Te6edf3discoveryDao Tff7b72= Te6edf3discoveryDaoTb4b4b4,
Te6edf3meshPrefs Tff7b72= Te6edf3meshPrefsTb4b4b4,
Te6edf3isScanActive Tff7b72= Tb4b4b4{ Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{ Te6edf3isActive Tb4b4b4} Tb4b4b4}Tb4b4b4,
Te6edf3scheduleRestoreIfIdle Tff7b72= Tb4b4b4{ Te6edf3session Tff7b72-Tff7b72>
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3isActive Tff7b72|Tff7b72| Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3value Tff7b72!Tff7b72= Te6edf3sessionTb4b4b4.Te6edf3deviceAddressTb4b4b4) Tb4b4b4{
Tff7b72null
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3homeRestorerTb4b4b4.Te6edf3schedulePersistedSessionTb4b4b4(Te6edf3sessionTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tff7b72private Tff7b72var Te6edf3scanScopeTb4b4b4: Te6edf3CoroutineScope? Tff7b72= Tff7b72null
Tff7b72private Tff7b72var Te6edf3dwellJobTb4b4b4: Te6edf3Job? Tff7b72= Tff7b72null
Tff7b72private Tff7b72var Te6edf3originalLoRaConfigTb4b4b4: Te6edf3ConfigTb4b4b4.Te6edf3LoRaConfigTff7b72? Tff7b72= Tff7b72null

T8b949e/** The radio's primary channel at scan start; restored after a custom-channel target overwrites it (FR-005). */
Tff7b72private Tff7b72var Te6edf3originalPrimaryChannelTb4b4b4: Te6edf3ChannelSettings? Tff7b72= Tff7b72null

T8b949e/** True once a custom-channel target has retuned the primary channel, so restore only writes when needed. */
Tff7b72private Tff7b72var Te6edf3tunedPrimaryChannelTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72false
Tff7b72private Tff7b72var Te6edf3sessionIdTb4b4b4: Tffa657Long Tff7b72= T79c0ff0

T8b949e/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
Tff7b72private Tff7b72val Te6edf3collectedNodes Tff7b72= Te6edf3mutableMapOfTff7b72<Tffa657LongTb4b4b4, Te6edf3CollectedNodeDataTff7b72>Tb4b4b4(Tb4b4b4)

T8b949e/** DeviceMetrics entries per node for the 2-packet rule. Keyed by nodeNum. */
Tff7b72private Tff7b72val Te6edf3deviceMetricsLog Tff7b72= Te6edf3mutableMapOfTff7b72<Tffa657LongTb4b4b4, Te6edf3MutableListTff7b72<Te6edf3DeviceMetricsEntryTff7b72>Tff7b72>Tb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72var Te6edf3currentPresetNameTb4b4b4: Tffa657String Tff7b72= Ta5d6ff"Ta5d6ff"
Tff7b72private Tff7b72var Te6edf3totalDwellSecondsTb4b4b4: Tffa657Long Tff7b72= T79c0ff0

T8b949e/** Guards exact-once result insertion when terminal cleanup races normal dwell completion. Protected by [mutex]. */
Tff7b72private Tff7b72var Te6edf3currentDwellPersistedTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72false
Tff7b72private Tff7b72var Te6edf3lastLocalStatsTb4b4b4: Te6edf3orgTb4b4b4.Te6edf3meshtasticTb4b4b4.Te6edf3protoTb4b4b4.Te6edf3LocalStatsTff7b72? Tff7b72= Tff7b72null

T8b949e// endregion

T8b949e// region Internal data classes

Tff7b72private Tff7b72data Tff7b72class T56d364CollectedNodeDataTb4b4b4(
Tff7b72var Te6edf3nodeNumTb4b4b4: Tffa657LongTb4b4b4,
Tff7b72var Te6edf3shortNameTb4b4b4: Tffa657String? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3longNameTb4b4b4: Tffa657String? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3neighborTypeTb4b4b4: Tffa657String Tff7b72= Ta5d6ff"Ta5d6ffdirectTa5d6ff"Tb4b4b4,
Tff7b72var Te6edf3latitudeTb4b4b4: Tffa657Double? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3longitudeTb4b4b4: Tffa657Double? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3snrTb4b4b4: Tffa657Float Tff7b72= T79c0ff0fTb4b4b4,
T8b949e/** Null until a packet reports one, so an absent reading stays distinct from a valid 0 dBm. */
Tff7b72var Te6edf3rssiTb4b4b4: Tffa657Int? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3hopCountTb4b4b4: Tffa657Int Tff7b72= T79c0ff0Tb4b4b4,
Tff7b72var Te6edf3messageCountTb4b4b4: Tffa657Int Tff7b72= T79c0ff0Tb4b4b4,
Tff7b72var Te6edf3sensorPacketCountTb4b4b4: Tffa657Int Tff7b72= T79c0ff0Tb4b4b4,
Tff7b72var Te6edf3isInfrastructureTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72falseTb4b4b4,
Tb4b4b4)

Tff7b72private Tff7b72data Tff7b72class T56d364DeviceMetricsEntryTb4b4b4(Tff7b72val Te6edf3timestampTb4b4b4: Tffa657LongTb4b4b4, Tff7b72val Te6edf3channelUtilTb4b4b4: Tffa657DoubleTb4b4b4, Tff7b72val Te6edf3airUtilTxTb4b4b4: Tffa657DoubleTb4b4b4)

T8b949e// endregion

T8b949e// region Public API

T8b949e/** Convenience overload: scan a list of public presets (each becomes a preset [ScanTarget] labelled by name). */
Tff7b72suspend Tff7b72fun Td2a8ffstartScanTb4b4b4(Te6edf3presetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ChannelOptionTff7b72>Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4) Tff7b72=
Te6edf3startScanTargetsTb4b4b4(Te6edf3presetsTb4b4b4.Te6edf3map Tb4b4b4{ Te6edf3ScanTargetTb4b4b4(Te6edf3preset Tff7b72= Tffa657itTb4b4b4, Te6edf3label Tff7b72= Tffa657itTb4b4b4.Te6edf3nameTb4b4b4) Tb4b4b4}Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4)

T8b949e/**
* Starts a discovery scan across the given [targets] — public presets and/or beacon-advertised custom channels.
* (Distinct name from the [ChannelOption] overload: both would erase to the same JVM signature otherwise.)
*
* @param targets The scan queue ([ScanTarget]).
* @param dwellDurationSeconds How long to listen on each target.
*/
Tff7b72suspend Tff7b72fun Td2a8ffstartScanTargetsTb4b4b4(Te6edf3targetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ScanTargetTff7b72>Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Te6edf3requireTb4b4b4(Te6edf3targetsTb4b4b4.Te6edf3isNotEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffAt least one scan target is requiredTa5d6ff" Tb4b4b4}
Te6edf3requireTb4b4b4(Te6edf3dwellDurationSeconds Tff7b72> T79c0ff0Tb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffDwell duration must be positiveTa5d6ff" Tb4b4b4}

Tff7b72if Tb4b4b4(Tff7b72!Te6edf3prepareScanStartTb4b4b4(Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3valueTb4b4b4)Tb4b4b4) Tff7b72return

T8b949e// These repository flows may suspend while their initial snapshots load. Keep that wait outside the engine
T8b949e// mutex so stop/reset and packet collection remain responsive, then revalidate device and restore ownership
T8b949e// under the mutex before publishing any scan state. Capture the baseline after admission waits so a transport
T8b949e// restart during those waits is not misreported as a change that happened during snapshot preparation.
Tff7b72val Te6edf3deviceAddress Tff7b72= Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3value
Tff7b72val Te6edf3sessionGeneration Tff7b72= Te6edf3radioControllerTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3value
Tff7b72val Te6edf3snapshot Tff7b72= Te6edf3readInitialConfigSnapshotTb4b4b4(Tb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3snapshot Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3refuseScanTb4b4b4(Ta5d6ff"Ta5d6ffRadio configuration snapshot is not availableTa5d6ff"Tb4b4b4)
Tff7b72return
Tb4b4b4}
Tff7b72val Tb4b4b4(Te6edf3homeLoraTb4b4b4, Te6edf3initialPrimaryChannelTb4b4b4) Tff7b72= Te6edf3snapshot
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3isActiveTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: scan already active, ignoring startScanTa5d6ff" Tb4b4b4}
Tb4b4b4} Tff7b72else Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isScanPreparationCurrentTb4b4b4(Te6edf3deviceAddressTb4b4b4, Te6edf3sessionGenerationTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3FailedTb4b4b4(Ta5d6ff"Ta5d6ffSelected radio changed while preparing the scanTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72else Tff7b72if Tb4b4b4(Te6edf3homeRestorerTb4b4b4.Te6edf3hasUnsatisfiedRestoreForTb4b4b4(Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3valueTb4b4b4)Tb4b4b4) Tb4b4b4{
T8b949e// Interrupted-session recovery registers its restore under this mutex. Re-check here so a restore that
T8b949e// won the race after the first wait cannot retune the radio underneath this scan.
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3FailedTb4b4b4(Ta5d6ff"Ta5d6ffHome configuration restoration is incompleteTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Preparing
Tff7b72val Te6edf3requiresPrimaryRestore Tff7b72= Te6edf3targetsTb4b4b4.Te6edf3any Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3channel Tff7b72!Tff7b72= Tff7b72null Tb4b4b4}
Tff7b72when Tb4b4b4{
Te6edf3homeLora Tff7b72=Tff7b72= Tff7b72null Tff7b72-Tff7b72> Te6edf3refuseUnrestorableScanLockedTb4b4b4(Ta5d6ff"Ta5d6ffHome LoRa configuration is not availableTa5d6ff"Tb4b4b4)

Te6edf3initialPrimaryChannel Tff7b72=Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3requiresPrimaryRestore Tff7b72-Tff7b72>
Te6edf3refuseUnrestorableScanLockedTb4b4b4(Ta5d6ff"Ta5d6ffPrimary channel is not available for a custom-channel scanTa5d6ff"Tb4b4b4)

Tff7b72else Tff7b72-Tff7b72>
Te6edf3prepareScanLockedTb4b4b4(
Te6edf3targets Tff7b72= Te6edf3targetsTb4b4b4,
Te6edf3dwellDurationSeconds Tff7b72= Te6edf3dwellDurationSecondsTb4b4b4,
Te6edf3initialLoraConfig Tff7b72= Te6edf3homeLoraTb4b4b4,
Te6edf3initialPrimaryChannel Tff7b72= Te6edf3initialPrimaryChannelTb4b4b4,
Te6edf3requiresPrimaryRestore Tff7b72= Te6edf3requiresPrimaryRestoreTb4b4b4,
Te6edf3deviceAddress Tff7b72= Te6edf3deviceAddressTb4b4b4,
Te6edf3sessionGeneration Tff7b72= Te6edf3sessionGenerationTb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffprepareScanStartTb4b4b4(Te6edf3deviceAddressTb4b4b4: Tffa657String?Tb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72= Tff7b72when Tb4b4b4{
Te6edf3deviceAddress Tff7b72=Tff7b72= Tff7b72null Tff7b72-Tff7b72> Tb4b4b4{
Te6edf3refuseScanTb4b4b4(Ta5d6ff"Ta5d6ffSelected radio is not availableTa5d6ff"Tb4b4b4)
Tff7b72false
Tb4b4b4}

Tff7b72!Te6edf3terminalCoordinatorTb4b4b4.Te6edf3resetForScanTb4b4b4(Tb4b4b4) Tff7b72-Tff7b72> Tb4b4b4{
Te6edf3refuseScanTb4b4b4(Ta5d6ff"Ta5d6ffPrevious discovery scan cleanup is still runningTa5d6ff"Tb4b4b4)
Tff7b72false
Tb4b4b4}

Tff7b72!Te6edf3homeRestorerTb4b4b4.Te6edf3awaitBeforeScanTb4b4b4(Te6edf3deviceAddressTb4b4b4) Tff7b72-Tff7b72> Tb4b4b4{
Te6edf3refuseScanTb4b4b4(Ta5d6ff"Ta5d6ffHome configuration restoration is incompleteTa5d6ff"Tb4b4b4)
Tff7b72false
Tb4b4b4}

Tff7b72else Tff7b72-Tff7b72> Tff7b72true
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffreadInitialConfigSnapshotTb4b4b4(Tb4b4b4)Tb4b4b4: Te6edf3PairTff7b72<Te6edf3ConfigTb4b4b4.Te6edf3LoRaConfigTff7b72?Tb4b4b4, Te6edf3ChannelSettings?Tff7b72>Tff7b72? Tff7b72=
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3CONFIG_SNAPSHOT_TIMEOUT_MSTb4b4b4) Tb4b4b4{
Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3localConfigFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3lora Te6edf3to
Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3channelSetFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3settingsTb4b4b4.Te6edf3firstOrNullTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrefuseScanTb4b4b4(Te6edf3reasonTb4b4b4: Tffa657StringTb4b4b4) Tb4b4b4{
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{ Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3FailedTb4b4b4(Te6edf3reasonTb4b4b4) Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffrefuseUnrestorableScanLockedTb4b4b4(Te6edf3reasonTb4b4b4: Tffa657StringTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: refusing unrestorable scan: Tffd700$Te6edf3reasonTa5d6ff" Tb4b4b4}
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3FailedTb4b4b4(Te6edf3reasonTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffprepareScanLockedTb4b4b4(
Te6edf3targetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ScanTargetTff7b72>Tb4b4b4,
Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4,
Te6edf3initialLoraConfigTb4b4b4: Te6edf3ConfigTb4b4b4.Te6edf3LoRaConfigTb4b4b4,
Te6edf3initialPrimaryChannelTb4b4b4: Te6edf3ChannelSettings?Tb4b4b4,
Te6edf3requiresPrimaryRestoreTb4b4b4: Tffa657BooleanTb4b4b4,
Te6edf3deviceAddressTb4b4b4: Tffa657String?Tb4b4b4,
Te6edf3sessionGenerationTb4b4b4: Tffa657LongTb4b4b4,
Tb4b4b4) Tb4b4b4{
Te6edf3originalLoRaConfig Tff7b72= Te6edf3initialLoraConfig
Te6edf3originalPrimaryChannel Tff7b72= Te6edf3initialPrimaryChannel
Te6edf3tunedPrimaryChannel Tff7b72= Tff7b72false
Tff7b72val Te6edf3homePresetStr Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3initialLoraConfigTb4b4b4.Te6edf3use_presetTb4b4b4) Tb4b4b4{
Te6edf3ChannelOptionTb4b4b4.Te6edf3fromTb4b4b4(Te6edf3initialLoraConfigTb4b4b4.Te6edf3modem_presetTb4b4b4)Tff7b72?.Te6edf3name Tff7b72?: Te6edf3ChannelOptionTb4b4b4.Te6edf3DEFAULTTb4b4b4.Te6edf3name
Tb4b4b4} Tff7b72else Tb4b4b4{
Ta5d6ff"Ta5d6ffCUSTOMTa5d6ff"
Tb4b4b4}
Tff7b72val Te6edf3myNodeNum Tff7b72= Te6edf3nodeRepositoryTb4b4b4.Te6edf3myNodeInfoTb4b4b4.Te6edf3valueTff7b72?.Te6edf3myNodeNum
Tff7b72val Te6edf3myPosition Tff7b72= Te6edf3myNodeNumTff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeRepositoryTb4b4b4.Te6edf3nodeDBbyNumTb4b4b4.Te6edf3valueTff7b72[Tffa657itTff7b72]Tff7b72?.Te6edf3position Tb4b4b4}
Tff7b72val Te6edf3session Tff7b72=
Te6edf3DiscoverySessionEntityTb4b4b4(
Te6edf3timestamp Tff7b72= Te6edf3nowMillisTb4b4b4,
Te6edf3presetsScanned Tff7b72= Te6edf3targetsTb4b4b4.Te6edf3joinToStringTb4b4b4(Ta5d6ff"Ta5d6ff,Ta5d6ff"Tb4b4b4) Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3label Tb4b4b4}Tb4b4b4,
Te6edf3homePreset Tff7b72= Te6edf3homePresetStrTb4b4b4,
Te6edf3completionStatus Tff7b72= Te6edf3DiscoverySessionStatusTb4b4b4.Te6edf3IN_PROGRESSTb4b4b4,
Te6edf3userLatitude Tff7b72= Tb4b4b4(Te6edf3myPositionTff7b72?.Te6edf3latitude_i Tff7b72?: T79c0ff0Tb4b4b4)Tb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISORTb4b4b4,
Te6edf3userLongitude Tff7b72= Tb4b4b4(Te6edf3myPositionTff7b72?.Te6edf3longitude_i Tff7b72?: T79c0ff0Tb4b4b4)Tb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISORTb4b4b4,
Te6edf3deviceAddress Tff7b72= Te6edf3deviceAddressTb4b4b4,
Te6edf3homeLoraConfig Tff7b72= Te6edf3initialLoraConfigTb4b4b4,
Te6edf3homePrimaryChannel Tff7b72= Te6edf3initialPrimaryChannelTb4b4b4.Te6edf3takeIf Tb4b4b4{ Te6edf3requiresPrimaryRestore Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tff7b72val Te6edf3insertedSessionId Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3insertSessionTb4b4b4(Te6edf3sessionTb4b4b4)
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isScanPreparationCurrentTb4b4b4(Te6edf3deviceAddressTb4b4b4, Te6edf3sessionGenerationTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3discoveryDaoTb4b4b4.Te6edf3deleteSessionTb4b4b4(Te6edf3insertedSessionIdTb4b4b4)
Te6edf3sessionId Tff7b72= T79c0ff0L
Te6edf3originalLoRaConfig Tff7b72= Tff7b72null
Te6edf3originalPrimaryChannel Tff7b72= Tff7b72null
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3FailedTb4b4b4(Ta5d6ff"Ta5d6ffSelected radio changed while preparing the scanTa5d6ff"Tb4b4b4)
Tff7b72return
Tb4b4b4}
Te6edf3sessionId Tff7b72= Te6edf3insertedSessionId
Te6edf3_currentSessionTb4b4b4.Te6edf3value Tff7b72= Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3id Tff7b72= Te6edf3sessionIdTb4b4b4)
Te6edf3collectorRegistryTb4b4b4.Te6edf3collector Tff7b72= Tff7b72this
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ShiftingTb4b4b4(Te6edf3targetsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3labelTb4b4b4)
Te6edf3currentPresetName Tff7b72= Te6edf3targetsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3label
Te6edf3totalDwellSeconds Tff7b72= Te6edf3dwellDurationSeconds
Te6edf3currentDwellPersisted Tff7b72= Tff7b72false
Te6edf3CoroutineScopeTb4b4b4(Te6edf3dispatchersTb4b4b4.Te6edf3io Tff7b72+ Te6edf3SupervisorJobTb4b4b4(Tb4b4b4)Tb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3scope Tff7b72-Tff7b72>
Te6edf3scanScope Tff7b72= Te6edf3scope
Te6edf3scopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3runScanLoopTb4b4b4(Te6edf3targetsTb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4) Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffisScanPreparationCurrentTb4b4b4(Te6edf3deviceAddressTb4b4b4: Tffa657String?Tb4b4b4, Te6edf3sessionGenerationTb4b4b4: Tffa657LongTb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72=
Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3deviceAddress Tff7b72&Tff7b72&
Te6edf3radioControllerTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3sessionGeneration Tff7b72&Tff7b72&
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected

T8b949e/** Stops the active scan and restores the home preset. */
Tff7b72suspend Tff7b72fun Td2a8ffstopScanTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3request Tff7b72=
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Tb4b4b4{
Tff7b72null
Tb4b4b4} Tff7b72else Tff7b72if Tb4b4b4(Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3AnalysisTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: ignoring stop after terminal analysis has startedTa5d6ff" Tb4b4b4}
Tff7b72null
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CancellingTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: stopping scanTa5d6ff" Tb4b4b4}
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Cancelling
Tb4b4b4}
T8b949e// Freeze the scan generation before snapshotting its restore plan.
T8b949e// No later target shift may race this terminal request.
Te6edf3cancelScanInternalTb4b4b4(Tb4b4b4)
Te6edf3terminalRequestLockedTb4b4b4(
Te6edf3pendingStatus Tff7b72= Te6edf3DiscoverySessionStatusTb4b4b4.Te6edf3RESTORE_PENDING_STOPPEDTb4b4b4,
Te6edf3outcome Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4.Te6edf3CancelledTb4b4b4,
Te6edf3awaitRestore Tff7b72= Tff7b72falseTb4b4b4,
Te6edf3generateAi Tff7b72= Tff7b72falseTb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3request Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3terminalCoordinatorTb4b4b4.Te6edf3completeTb4b4b4(Te6edf3request Tff7b72= Te6edf3requestTb4b4b4, Te6edf3beforeFinalize Tff7b72= Tff7b72::Te6edf3persistCurrentDwellResultsTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/** Resets engine state after the UI has acknowledged completion. */
Tff7b72fun Td2a8ffresetTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3mutexTb4b4b4.Te6edf3tryLockTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: ignoring reset while scan state is changingTa5d6ff" Tb4b4b4}
Tff7b72return
Tb4b4b4}
Tff7b72try Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3isActiveTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: ignoring reset while scan cleanup is still activeTa5d6ff" Tb4b4b4}
Tff7b72return
Tb4b4b4}
Te6edf3_currentSessionTb4b4b4.Te6edf3value Tff7b72= Tff7b72null
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Idle
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3mutexTb4b4b4.Te6edf3unlockTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// endregion

T8b949e// region DiscoveryPacketCollector

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffonPacketReceivedTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4, Te6edf3dataPacketTb4b4b4: Te6edf3DataPacketTb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3DwellTb4b4b4) Tff7b72return
Tff7b72val Te6edf3fromNum Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3fromTb4b4b4.Te6edf3toLongTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3portNum Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3portnum Tff7b72?: Tff7b72return

Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72val Te6edf3node Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3getOrPutTb4b4b4(Te6edf3fromNumTb4b4b4) Tb4b4b4{ Te6edf3CollectedNodeDataTb4b4b4(Te6edf3nodeNum Tff7b72= Te6edf3fromNumTb4b4b4) Tb4b4b4}
T8b949e// Update signal info from the direct packet
T8b949e// Explicit presence: record a reported 0 dB/0 dBm, skip only a genuinely absent one.
Te6edf3meshPacketTb4b4b4.Te6edf3snrOrNullTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeTb4b4b4.Te6edf3snr Tff7b72= Tffa657it Tb4b4b4}
Te6edf3meshPacketTb4b4b4.Te6edf3rx_rssiTff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeTb4b4b4.Te6edf3rssi Tff7b72= Tffa657it Tb4b4b4}
Te6edf3nodeTb4b4b4.Te6edf3hopCount Tff7b72= Te6edf3dataPacketTb4b4b4.Te6edf3hopsAwayTb4b4b4.Te6edf3coerceAtLeastTb4b4b4(T79c0ff0Tb4b4b4)

Tff7b72when Tb4b4b4(Te6edf3portNumTb4b4b4) Tb4b4b4{
Te6edf3PortNumTb4b4b4.Te6edf3TEXT_MESSAGE_APP Tff7b72-Tff7b72> Te6edf3nodeTb4b4b4.Te6edf3messageCountTff7b72+Tff7b72+
Te6edf3PortNumTb4b4b4.Te6edf3POSITION_APP Tff7b72-Tff7b72> Te6edf3handlePositionTb4b4b4(Te6edf3meshPacketTb4b4b4, Te6edf3nodeTb4b4b4)
Te6edf3PortNumTb4b4b4.Te6edf3TELEMETRY_APP Tff7b72-Tff7b72> Te6edf3handleTelemetryTb4b4b4(Te6edf3meshPacketTb4b4b4, Te6edf3nodeTb4b4b4, Te6edf3fromNumTb4b4b4)
Te6edf3PortNumTb4b4b4.Te6edf3NEIGHBORINFO_APP Tff7b72-Tff7b72> Te6edf3handleNeighborInfoTb4b4b4(Te6edf3meshPacketTb4b4b4)
Tff7b72else Tff7b72-Tff7b72> Tffa657Unit
Tb4b4b4}

T8b949e// Enrich the sending node from the local NodeDB (names/position fallback)
Te6edf3enrichNodeFromDbTb4b4b4(Te6edf3nodeTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/** Backfills name, position, and infrastructure role from the local NodeDB when not yet received over-the-air. */
Tff7b72private Tff7b72fun Td2a8ffenrichNodeFromDbTb4b4b4(Te6edf3nodeTb4b4b4: Te6edf3CollectedNodeDataTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3dbNode Tff7b72= Te6edf3nodeRepositoryTb4b4b4.Te6edf3nodeDBbyNumTb4b4b4.Te6edf3valueTff7b72[Te6edf3nodeTb4b4b4.Te6edf3nodeNumTb4b4b4.Te6edf3toIntTb4b4b4(Tb4b4b4)Tff7b72] Tff7b72?: Tff7b72return
Tff7b72if Tb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3shortName Tff7b72=Tff7b72= Tff7b72null Tff7b72|Tff7b72| Te6edf3nodeTb4b4b4.Te6edf3longName Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3nodeTb4b4b4.Te6edf3shortName Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3userTb4b4b4.Te6edf3short_nameTb4b4b4.Te6edf3ifBlank Tb4b4b4{ Tff7b72null Tb4b4b4}
Te6edf3nodeTb4b4b4.Te6edf3longName Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3userTb4b4b4.Te6edf3long_nameTb4b4b4.Te6edf3ifBlank Tb4b4b4{ Tff7b72null Tb4b4b4}
Tb4b4b4}
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3hasValidCoordinatesTb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3latitudeTb4b4b4, Te6edf3nodeTb4b4b4.Te6edf3longitudeTb4b4b4)Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3dbLat Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3positionTb4b4b4.Te6edf3latitude_i
Tff7b72val Te6edf3dbLon Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3positionTb4b4b4.Te6edf3longitude_i
Tff7b72if Tb4b4b4(Te6edf3dbLat Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3dbLat Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3latitude Tff7b72= Te6edf3dbLatTb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISOR
Tff7b72if Tb4b4b4(Te6edf3dbLon Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3dbLon Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3longitude Tff7b72= Te6edf3dbLonTb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISOR
Tb4b4b4}
Te6edf3nodeTb4b4b4.Te6edf3isInfrastructure Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3userTb4b4b4.Te6edf3role Tff7b72in Te6edf3INFRASTRUCTURE_ROLES
Tb4b4b4}

T8b949e// endregion

T8b949e// region Scan loop

Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffReturnCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrunScanLoopTb4b4b4(Te6edf3targetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ScanTargetTff7b72>Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Tff7b72for Tb4b4b4(Te6edf3target Tff7b72in Te6edf3targetsTb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Tff7b72return

Tff7b72val Te6edf3shouldShift Tff7b72=
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3canAdvanceScanLockedTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Tff7b72false
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3currentPresetName Tff7b72= Te6edf3targetTb4b4b4.Te6edf3label
Te6edf3totalDwellSeconds Tff7b72= Te6edf3dwellDurationSeconds
Te6edf3currentDwellPersisted Tff7b72= Tff7b72false
Te6edf3collectedNodesTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)
Te6edf3deviceMetricsLogTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)
Te6edf3lastLocalStats Tff7b72= Tff7b72null
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ShiftingTb4b4b4(Te6edf3targetTb4b4b4.Te6edf3labelTb4b4b4)
Tff7b72true
Tb4b4b4}
Tb4b4b4}
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3shouldShiftTb4b4b4) Tff7b72return

T8b949e// Shift to the new target (preset, plus a custom primary channel for beacon-channel targets)
Tff7b72try Tb4b4b4{
Te6edf3shiftTargetTb4b4b4(Te6edf3targetTb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4) Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3eTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: target shift failed; aborting scan and restoring home configTa5d6ff" Tb4b4b4}
Te6edf3pauseAndAbortTb4b4b4(Tb4b4b4)
Tff7b72return
Tb4b4b4}

T8b949e// Wait for reconnection
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3transitionScanStateTb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ReconnectingTb4b4b4(Te6edf3targetTb4b4b4.Te6edf3labelTb4b4b4)Tb4b4b4)Tb4b4b4) Tff7b72return
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3waitForConnectionTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3pauseAndAbortTb4b4b4(Te6edf3persistPartialDwell Tff7b72= Tff7b72trueTb4b4b4)
Tff7b72return
Tb4b4b4}

T8b949e// Request neighbor info at dwell start to seed mesh topology data (D020)
Te6edf3requestNeighborInfoAtDwellBoundaryTb4b4b4(Tb4b4b4)

T8b949e// Dwell
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3runDwellTb4b4b4(Te6edf3targetTb4b4b4.Te6edf3labelTb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3pauseAndAbortTb4b4b4(Te6edf3persistPartialDwell Tff7b72= Tff7b72trueTb4b4b4)
Tff7b72return
Tb4b4b4}
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Tff7b72return

T8b949e// Persist this target's results
Te6edf3persistCurrentDwellResultsTb4b4b4(Tb4b4b4)
Tb4b4b4}

T8b949e// Elect normal completion under the same mutex used by stopScan so a late stop cannot replace its outcome.
Tff7b72val Te6edf3request Tff7b72=
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Te6edf3collectorRegistryTb4b4b4.Te6edf3collector Tff7b72= Tff7b72null
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Analysis
Te6edf3terminalRequestLockedTb4b4b4(
Te6edf3pendingStatus Tff7b72= Te6edf3DiscoverySessionStatusTb4b4b4.Te6edf3RESTORE_PENDING_COMPLETETb4b4b4,
Te6edf3outcome Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4.Te6edf3SuccessTb4b4b4,
Te6edf3awaitRestore Tff7b72= Tff7b72trueTb4b4b4,
Te6edf3generateAi Tff7b72= Tff7b72trueTb4b4b4,
Tb4b4b4)
Tb4b4b4}
T8b949e// complete() cancels scanScope before terminal cleanup finishes. This scan coroutine ends inside the call;
T8b949e// follow-up work belongs in terminal-coordinator callbacks, not after this invocation.
Te6edf3terminalCoordinatorTb4b4b4.Te6edf3completeTb4b4b4(Te6edf3request Tff7b72= Te6edf3requestTb4b4b4, Te6edf3generateAi Tff7b72= Tff7b72::Te6edf3generateAiSummariesTb4b4b4)
Tb4b4b4}

T8b949e/** Common cleanup path when a scan step fails mid-loop. */
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpauseAndAbortTb4b4b4(Te6edf3persistPartialDwellTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72falseTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3request Tff7b72=
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CancellingTb4b4b4) Tb4b4b4{
Tff7b72null
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Analysis
Te6edf3terminalRequestLockedTb4b4b4(
Te6edf3pendingStatus Tff7b72= Te6edf3DiscoverySessionStatusTb4b4b4.Te6edf3RESTORE_PENDING_FAILEDTb4b4b4,
Te6edf3outcome Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4.Te6edf3FailedTb4b4b4,
Te6edf3awaitRestore Tff7b72= Tff7b72falseTb4b4b4,
Te6edf3generateAi Tff7b72= Tff7b72falseTb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3request Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tff7b72return
Tff7b72if Tb4b4b4(Te6edf3persistPartialDwellTb4b4b4) Tb4b4b4{
Te6edf3terminalCoordinatorTb4b4b4.Te6edf3completeTb4b4b4(Te6edf3request Tff7b72= Te6edf3requestTb4b4b4, Te6edf3beforeFinalize Tff7b72= Tff7b72::Te6edf3persistCurrentDwellResultsTb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3terminalCoordinatorTb4b4b4.Te6edf3completeTb4b4b4(Te6edf3request Tff7b72= Te6edf3requestTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffshiftTargetTb4b4b4(Te6edf3targetTb4b4b4: Te6edf3ScanTargetTb4b4b4) Tb4b4b4{
T8b949e// Start from the captured original config so unrelated fields (hop_limit, tx_power, tx_enabled, …) are carried
T8b949e// over instead of zeroed by a fresh LoRaConfig — a from-scratch config can e.g. break the dwell-boundary
T8b949e// NeighborInfo request. Only the preset (and, for custom channels, region + channel_num) is overridden.
Tff7b72val Te6edf3base Tff7b72= Te6edf3originalLoRaConfig Tff7b72?: Te6edf3ConfigTb4b4b4.Te6edf3LoRaConfigTb4b4b4(Tb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3targetTb4b4b4.Te6edf3channel Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
T8b949e// Public-preset target — dwell on the preset using the radio's existing primary channel (unchanged).
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalConfigTb4b4b4(
Te6edf3ConfigTb4b4b4(Te6edf3lora Tff7b72= Te6edf3baseTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3use_preset Tff7b72= Tff7b72trueTb4b4b4, Te6edf3modem_preset Tff7b72= Te6edf3targetTb4b4b4.Te6edf3presetTb4b4b4.Te6edf3modemPresetTb4b4b4)Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: shifted to Tffd700${Te6edf3targetTb4b4b4.Te6edf3labelTffd700}Ta5d6ff (use_preset=true)Ta5d6ff" Tb4b4b4}
Tb4b4b4} Tff7b72else Tb4b4b4{
T8b949e// Beacon custom-channel target: apply the offered preset+region, reset channel_num so firmware derives the
T8b949e// frequency from the new name, then tune the primary channel to the offered name+PSK so nodes on that mesh
T8b949e// are heard. The original primary channel is restored after the scan.
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalConfigTb4b4b4(
Te6edf3ConfigTb4b4b4(
Te6edf3lora Tff7b72=
Te6edf3baseTb4b4b4.Te6edf3copyTb4b4b4(
Te6edf3use_preset Tff7b72= Tff7b72trueTb4b4b4,
Te6edf3modem_preset Tff7b72= Te6edf3targetTb4b4b4.Te6edf3presetTb4b4b4.Te6edf3modemPresetTb4b4b4,
Te6edf3region Tff7b72= Te6edf3targetTb4b4b4.Te6edf3region Tff7b72?: Te6edf3baseTb4b4b4.Te6edf3regionTb4b4b4,
Te6edf3channel_num Tff7b72= T79c0ff0Tb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Te6edf3currentCoroutineContextTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3ensureActiveTb4b4b4(Tb4b4b4)
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{ Te6edf3tunedPrimaryChannel Tff7b72= Tff7b72true Tb4b4b4}
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalChannelTb4b4b4(Te6edf3ChannelTb4b4b4(Te6edf3index Tff7b72= T79c0ff0Tb4b4b4, Te6edf3role Tff7b72= Te6edf3ChannelTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3PRIMARYTb4b4b4, Te6edf3settings Tff7b72= Te6edf3targetTb4b4b4.Te6edf3channelTb4b4b4)Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: shifted to Tffd700${Te6edf3targetTb4b4b4.Te6edf3labelTffd700}Ta5d6ff (custom channel)Ta5d6ff" Tb4b4b4}
Tb4b4b4}
T8b949e// The firmware often restarts the radio or reboots after a LoRa config change.
T8b949e// Wait a short moment to ensure we don't consider it 'connected' right before it drops.
Te6edf3delayTb4b4b4(T79c0ff3T79c0ff0T79c0ff0T79c0ff0Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffwaitForConnectionTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657Boolean Tb4b4b4{
Tff7b72val Te6edf3result Tff7b72=
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3RECONNECT_TIMEOUT_MSTb4b4b4) Tb4b4b4{
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3first Tb4b4b4{ Tffa657it Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected Tb4b4b4}
Tb4b4b4}
Tff7b72return Te6edf3result Tff7b72!Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e/**
* Requests NeighborInfo from the local node at each dwell boundary to seed mesh topology data. The response arrives
* via the normal packet pipeline → [handleNeighborInfo].
*/
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrequestNeighborInfoAtDwellBoundaryTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3myNodeNum Tff7b72= Te6edf3nodeRepositoryTb4b4b4.Te6edf3myNodeInfoTb4b4b4.Te6edf3valueTff7b72?.Te6edf3myNodeNum Tff7b72?: Tff7b72return
Tff7b72val Te6edf3packetId Tff7b72= Te6edf3radioControllerTb4b4b4.Te6edf3generatePacketIdTb4b4b4(Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3radioControllerTb4b4b4.Te6edf3requestNeighborInfoTb4b4b4(Te6edf3packetIdTb4b4b4, Te6edf3myNodeNumTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: requested NeighborInfo from local node Tffd700$Te6edf3myNodeNumTa5d6ff (packetId=Tffd700$Te6edf3packetIdTa5d6ff)Ta5d6ff" Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4) Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: NeighborInfo request failed; continuing the dwellTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrunDwellTb4b4b4(Te6edf3presetNameTb4b4b4: Tffa657StringTb4b4b4, Te6edf3durationSecondsTb4b4b4: Tffa657LongTb4b4b4)Tb4b4b4: Tffa657Boolean Tb4b4b4{
Tff7b72var Te6edf3remaining Tff7b72= Te6edf3durationSeconds
Tff7b72var Te6edf3canContinue Tff7b72= Tff7b72true
Tff7b72while Tb4b4b4(Te6edf3remaining Tff7b72> T79c0ff0 Tff7b72&Tff7b72& Te6edf3canContinueTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3isConnected Tff7b72= Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isConnectedTb4b4b4) Tb4b4b4{
Te6edf3canContinue Tff7b72= Te6edf3transitionScanStateTb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ReconnectingTb4b4b4(Te6edf3presetNameTb4b4b4)Tb4b4b4) Tff7b72&Tff7b72& Te6edf3waitForConnectionTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72val Te6edf3dwellState Tff7b72=
Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3DwellTb4b4b4(
Te6edf3presetName Tff7b72= Te6edf3presetNameTb4b4b4,
Te6edf3remainingSeconds Tff7b72= Te6edf3remainingTb4b4b4,
Te6edf3totalSeconds Tff7b72= Te6edf3durationSecondsTb4b4b4,
Tb4b4b4)
Te6edf3canContinue Tff7b72= Te6edf3transitionScanStateTb4b4b4(Te6edf3dwellStateTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3canContinueTb4b4b4) Tb4b4b4{
Te6edf3delayTb4b4b4(Te6edf3TICK_INTERVAL_MSTb4b4b4)
Te6edf3remainingTff7b72-Tff7b72-
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tff7b72return Te6edf3canContinue
Tb4b4b4}

T8b949e/** Caller must hold [mutex]. Terminal states are intentionally excluded even though Cancelling is active. */
Tff7b72private Tff7b72fun Td2a8ffcanAdvanceScanLockedTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72= Te6edf3isActive Tff7b72&Tff7b72&
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Cancelling Tff7b72&Tff7b72&
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Analysis

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8fftransitionScanStateTb4b4b4(Te6edf3stateTb4b4b4: Te6edf3DiscoveryScanStateTb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72= Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3canAdvanceScanLockedTb4b4b4(Tb4b4b4)Tb4b4b4) Tff7b72returnTf0883e@withLock Tff7b72false
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3state
Tff7b72true
Tb4b4b4}

T8b949e// endregion

T8b949e// region Packet handlers

Tff7b72private Tff7b72fun Td2a8ffhandlePositionTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4, Te6edf3nodeTb4b4b4: Te6edf3CollectedNodeDataTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3payload Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3payload Tff7b72?: Tff7b72return
Tff7b72val Te6edf3pos Tff7b72= Te6edf3PositionTb4b4b4.Te6edf3ADAPTERTb4b4b4.Te6edf3decodeOrNullTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3LoggerTb4b4b4) Tff7b72?: Tff7b72return
Tff7b72val Te6edf3lat Tff7b72= Te6edf3posTb4b4b4.Te6edf3latitude_i
Tff7b72val Te6edf3lon Tff7b72= Te6edf3posTb4b4b4.Te6edf3longitude_i
Tff7b72if Tb4b4b4(Te6edf3lat Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lat Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3latitude Tff7b72= Te6edf3lat Tff7b72/ Te6edf3POSITION_DIVISOR
Tff7b72if Tb4b4b4(Te6edf3lon Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lon Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3longitude Tff7b72= Te6edf3lon Tff7b72/ Te6edf3POSITION_DIVISOR
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleTelemetryTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4, Te6edf3nodeTb4b4b4: Te6edf3CollectedNodeDataTb4b4b4, Te6edf3fromNumTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3payload Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3payload Tff7b72?: Tff7b72return
Tff7b72val Te6edf3telemetry Tff7b72= Te6edf3TelemetryTb4b4b4.Te6edf3ADAPTERTb4b4b4.Te6edf3decodeOrNullTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3LoggerTb4b4b4) Tff7b72?: Tff7b72return

Tff7b72val Te6edf3deviceMetrics Tff7b72= Te6edf3telemetryTb4b4b4.Te6edf3device_metrics
Tff7b72if Tb4b4b4(Te6edf3deviceMetrics Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3entries Tff7b72= Te6edf3deviceMetricsLogTb4b4b4.Te6edf3getOrPutTb4b4b4(Te6edf3fromNumTb4b4b4) Tb4b4b4{ Te6edf3mutableListOfTb4b4b4(Tb4b4b4) Tb4b4b4}
Te6edf3entriesTb4b4b4.Te6edf3addTb4b4b4(
Te6edf3DeviceMetricsEntryTb4b4b4(
Te6edf3timestamp Tff7b72= Te6edf3nowMillisTb4b4b4,
Te6edf3channelUtil Tff7b72= Te6edf3deviceMetricsTb4b4b4.Te6edf3channel_utilizationTff7b72?.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72?: T79c0ff0.0Tb4b4b4,
Te6edf3airUtilTx Tff7b72= Te6edf3deviceMetricsTb4b4b4.Te6edf3air_util_txTff7b72?.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72?: T79c0ff0.0Tb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Tb4b4b4}

Tff7b72if Tb4b4b4(Te6edf3telemetryTb4b4b4.Te6edf3local_stats Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3lastLocalStats Tff7b72= Te6edf3telemetryTb4b4b4.Te6edf3local_stats
Tb4b4b4}

Tff7b72if Tb4b4b4(Te6edf3telemetryTb4b4b4.Te6edf3environment_metrics Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3nodeTb4b4b4.Te6edf3sensorPacketCountTff7b72+Tff7b72+
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleNeighborInfoTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3payload Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3payload Tff7b72?: Tff7b72return
Tff7b72val Te6edf3ni Tff7b72= Te6edf3NeighborInfoTb4b4b4.Te6edf3ADAPTERTb4b4b4.Te6edf3decodeOrNullTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3LoggerTb4b4b4) Tff7b72?: Tff7b72return
Tff7b72for Tb4b4b4(Te6edf3neighbor Tff7b72in Te6edf3niTb4b4b4.Te6edf3neighborsTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3neighborNum Tff7b72= Te6edf3neighborTb4b4b4.Te6edf3node_idTb4b4b4.Te6edf3toLongTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3node Tff7b72=
Te6edf3collectedNodesTb4b4b4.Te6edf3getOrPutTb4b4b4(Te6edf3neighborNumTb4b4b4) Tb4b4b4{ Te6edf3CollectedNodeDataTb4b4b4(Te6edf3nodeNum Tff7b72= Te6edf3neighborNumTb4b4b4, Te6edf3neighborType Tff7b72= Ta5d6ff"Ta5d6ffmeshTa5d6ff"Tb4b4b4) Tb4b4b4}
T8b949e// Only mark as mesh if not already seen directly
Tff7b72if Tb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3snr Tff7b72=Tff7b72= T79c0ff0f Tff7b72&Tff7b72& Te6edf3nodeTb4b4b4.Te6edf3rssi Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3nodeTb4b4b4.Te6edf3neighborType Tff7b72= Ta5d6ff"Ta5d6ffmeshTa5d6ff"
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e// endregion

T8b949e// region Persistence

Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffReturnCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffgenerateAiSummariesTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3sessionId Tff7b72=Tff7b72= T79c0ff0L Tff7b72|Tff7b72| Tff7b72!Te6edf3aiProviderTb4b4b4.Te6edf3isAvailableTb4b4b4) Tff7b72return
Tff7b72val Te6edf3session Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getSessionTb4b4b4(Te6edf3sessionIdTb4b4b4) Tff7b72?: Tff7b72return
Tff7b72val Te6edf3presetResults Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getPresetResultsTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3presetResultsTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tff7b72return

T8b949e// Generate per-preset AI summaries
Tff7b72for Tb4b4b4(Te6edf3result Tff7b72in Te6edf3presetResultsTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3presetSummary Tff7b72= Te6edf3aiProviderTb4b4b4.Te6edf3generatePresetSummaryTb4b4b4(Te6edf3resultTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3presetSummary Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3discoveryDaoTb4b4b4.Te6edf3updatePresetResultTb4b4b4(Te6edf3resultTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3aiSummary Tff7b72= Te6edf3presetSummaryTb4b4b4)Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// Generate session-level AI summary
Tff7b72val Te6edf3sessionSummary Tff7b72= Te6edf3aiProviderTb4b4b4.Te6edf3generateSessionSummaryTb4b4b4(Te6edf3sessionTb4b4b4, Te6edf3presetResultsTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3sessionSummary Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3discoveryDaoTb4b4b4.Te6edf3updateSessionTb4b4b4(Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3aiSummary Tff7b72= Te6edf3sessionSummaryTb4b4b4)Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistCurrentDwellResultsTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3sessionId Tff7b72=Tff7b72= T79c0ff0LTb4b4b4) Tff7b72return
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3currentDwellPersistedTb4b4b4) Tff7b72returnTf0883e@withLock
Tff7b72if Tb4b4b4(Te6edf3collectedNodesTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3persistEmptyPresetResultTb4b4b4(Tb4b4b4)
Te6edf3currentDwellPersisted Tff7b72= Tff7b72true
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72val Te6edf3presetResultId Tff7b72= Te6edf3persistPresetResultTb4b4b4(Tb4b4b4)
Te6edf3persistDiscoveredNodesTb4b4b4(Te6edf3presetResultIdTb4b4b4)
Te6edf3currentDwellPersisted Tff7b72= Tff7b72true
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistEmptyPresetResultTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3emptyResult Tff7b72=
Te6edf3DiscoveryPresetResultEntityTb4b4b4(
Te6edf3sessionId Tff7b72= Te6edf3sessionIdTb4b4b4,
Te6edf3presetName Tff7b72= Te6edf3currentPresetNameTb4b4b4,
Te6edf3dwellDurationSeconds Tff7b72= Te6edf3totalDwellSecondsTb4b4b4,
Tb4b4b4)
Te6edf3discoveryDaoTb4b4b4.Te6edf3insertPresetResultTb4b4b4(Te6edf3emptyResultTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistPresetResultTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657Long Tb4b4b4{
Tff7b72val Tb4b4b4(Te6edf3avgChannelUtilTb4b4b4, Te6edf3avgAirUtilTb4b4b4) Tff7b72= Te6edf3computeAverageMetricsTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3directCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3neighborType Tff7b72=Tff7b72= Ta5d6ff"Ta5d6ffdirectTa5d6ff" Tb4b4b4}
Tff7b72val Te6edf3meshCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3neighborType Tff7b72=Tff7b72= Ta5d6ff"Ta5d6ffmeshTa5d6ff" Tb4b4b4}
Tff7b72val Te6edf3infraCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3isInfrastructure Tb4b4b4}

Tff7b72val Te6edf3packetsRx Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_packets_rx Tff7b72?: T79c0ff0
Tff7b72val Te6edf3packetsRxBad Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_packets_rx_bad Tff7b72?: T79c0ff0
Tff7b72val Tb4b4b4(Te6edf3successRateTb4b4b4, Te6edf3failureRateTb4b4b4) Tff7b72= Te6edf3computePacketRatesTb4b4b4(Te6edf3packetsRxTb4b4b4, Te6edf3packetsRxBadTb4b4b4)

Tff7b72val Te6edf3presetResult Tff7b72=
Te6edf3DiscoveryPresetResultEntityTb4b4b4(
Te6edf3sessionId Tff7b72= Te6edf3sessionIdTb4b4b4,
Te6edf3presetName Tff7b72= Te6edf3currentPresetNameTb4b4b4,
Te6edf3dwellDurationSeconds Tff7b72= Te6edf3totalDwellSecondsTb4b4b4,
Te6edf3uniqueNodes Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3sizeTb4b4b4,
Te6edf3directNeighborCount Tff7b72= Te6edf3directCountTb4b4b4,
Te6edf3meshNeighborCount Tff7b72= Te6edf3meshCountTb4b4b4,
Te6edf3infrastructureNodeCount Tff7b72= Te6edf3infraCountTb4b4b4,
Te6edf3messageCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3messageCount Tb4b4b4}Tb4b4b4,
Te6edf3sensorPacketCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3sensorPacketCount Tb4b4b4}Tb4b4b4,
Te6edf3avgChannelUtilization Tff7b72= Te6edf3avgChannelUtilTb4b4b4,
Te6edf3avgAirtimeRate Tff7b72= Te6edf3avgAirUtilTb4b4b4,
Te6edf3packetSuccessRate Tff7b72= Te6edf3successRateTb4b4b4,
Te6edf3packetFailureRate Tff7b72= Te6edf3failureRateTb4b4b4,
Te6edf3numPacketsTx Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_packets_tx Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numPacketsRx Tff7b72= Te6edf3packetsRxTb4b4b4,
Te6edf3numPacketsRxBad Tff7b72= Te6edf3packetsRxBadTb4b4b4,
Te6edf3numRxDupe Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_rx_dupe Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numTxRelay Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_tx_relay Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numTxRelayCanceled Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_tx_relay_canceled Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numOnlineNodes Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_online_nodes Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numTotalNodes Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_total_nodes Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3uptimeSeconds Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3uptime_seconds Tff7b72?: T79c0ff0Tb4b4b4,
Tb4b4b4)
Tff7b72return Te6edf3discoveryDaoTb4b4b4.Te6edf3insertPresetResultTb4b4b4(Te6edf3presetResultTb4b4b4)
Tb4b4b4}

T8b949e/**
* Computes packet success and failure rates as percentages (0–100) from LocalStats counters. Returns (successRate,
* failureRate). Both are 0.0 if no packets were received.
*/
Tff7b72private Tff7b72fun Td2a8ffcomputePacketRatesTb4b4b4(Te6edf3packetsRxTb4b4b4: Tffa657IntTb4b4b4, Te6edf3packetsRxBadTb4b4b4: Tffa657IntTb4b4b4)Tb4b4b4: Te6edf3PairTff7b72<Tffa657DoubleTb4b4b4, Tffa657DoubleTff7b72> Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3packetsRx Tff7b72<Tff7b72= T79c0ff0Tb4b4b4) Tff7b72return T79c0ff0.0 Te6edf3to T79c0ff0.0
Tff7b72val Te6edf3failureRate Tff7b72= Tb4b4b4(Te6edf3packetsRxBadTb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3packetsRxTb4b4b4) Tff7b72* Te6edf3PERCENT_MULTIPLIER
Tff7b72val Te6edf3successRate Tff7b72= Te6edf3PERCENT_MULTIPLIER Tff7b72- Te6edf3failureRate
Tff7b72return Te6edf3successRate Te6edf3to Te6edf3failureRate
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistDiscoveredNodesTb4b4b4(Te6edf3presetResultIdTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3session Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getSessionTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tff7b72val Te6edf3userLat Tff7b72= Te6edf3sessionTff7b72?.Te6edf3userLatitude Tff7b72?: T79c0ff0.0
Tff7b72val Te6edf3userLon Tff7b72= Te6edf3sessionTff7b72?.Te6edf3userLongitude Tff7b72?: T79c0ff0.0

Tff7b72val Te6edf3nodeEntities Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3map Tb4b4b4{ Te6edf3data Tff7b72-Tff7b72> Te6edf3dataTb4b4b4.Te6edf3toEntityTb4b4b4(Te6edf3presetResultIdTb4b4b4, Te6edf3userLatTb4b4b4, Te6edf3userLonTb4b4b4) Tb4b4b4}
Te6edf3discoveryDaoTb4b4b4.Te6edf3insertDiscoveredNodesTb4b4b4(Te6edf3nodeEntitiesTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72fun Te6edf3CollectedNodeDataTb4b4b4.Td2a8fftoEntityTb4b4b4(
Te6edf3presetResultIdTb4b4b4: Tffa657LongTb4b4b4,
Te6edf3userLatTb4b4b4: Tffa657DoubleTb4b4b4,
Te6edf3userLonTb4b4b4: Tffa657DoubleTb4b4b4,
Tb4b4b4)Tb4b4b4: Te6edf3DiscoveredNodeEntity Tb4b4b4{
Tff7b72val Te6edf3distance Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3hasValidCoordinatesTb4b4b4(Te6edf3latitudeTb4b4b4, Te6edf3longitudeTb4b4b4) Tff7b72&Tff7b72& Te6edf3hasValidCoordinatesTb4b4b4(Te6edf3userLatTb4b4b4, Te6edf3userLonTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3latLongToMeterTb4b4b4(Te6edf3userLatTb4b4b4, Te6edf3userLonTb4b4b4, Te6edf3latitudeTff7b72!!Tb4b4b4, Te6edf3longitudeTff7b72!!Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72null
Tb4b4b4}
Tff7b72return Te6edf3DiscoveredNodeEntityTb4b4b4(
Te6edf3presetResultId Tff7b72= Te6edf3presetResultIdTb4b4b4,
Te6edf3nodeNum Tff7b72= Te6edf3nodeNumTb4b4b4,
Te6edf3shortName Tff7b72= Te6edf3shortNameTb4b4b4,
Te6edf3longName Tff7b72= Te6edf3longNameTb4b4b4,
Te6edf3neighborType Tff7b72= Te6edf3neighborTypeTb4b4b4,
Te6edf3latitude Tff7b72= Te6edf3latitudeTb4b4b4,
Te6edf3longitude Tff7b72= Te6edf3longitudeTb4b4b4,
Te6edf3distanceFromUser Tff7b72= Te6edf3distanceTb4b4b4,
Te6edf3hopCount Tff7b72= Te6edf3hopCountTb4b4b4,
Te6edf3snr Tff7b72= Te6edf3snrTb4b4b4,
Te6edf3rssi Tff7b72= Te6edf3rssiTb4b4b4,
Te6edf3messageCount Tff7b72= Te6edf3messageCountTb4b4b4,
Te6edf3sensorPacketCount Tff7b72= Te6edf3sensorPacketCountTb4b4b4,
Te6edf3isInfrastructure Tff7b72= Te6edf3isInfrastructureTb4b4b4,
Tb4b4b4)
Tb4b4b4}

T8b949e/** Returns true if both [lat] and [lon] are non-null and non-zero (i.e. a valid GPS fix). */
Tff7b72private Tff7b72fun Td2a8ffhasValidCoordinatesTb4b4b4(Te6edf3latTb4b4b4: Tffa657Double?Tb4b4b4, Te6edf3lonTb4b4b4: Tffa657Double?Tb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72=
Te6edf3lat Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lon Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lat Tff7b72!Tff7b72= T79c0ff0.0 Tff7b72&Tff7b72& Te6edf3lon Tff7b72!Tff7b72= T79c0ff0.0

T8b949e/**
* Computes average channel utilization and airtime from DeviceMetrics, applying the 2-packet rule (only nodes with
* ≥2 reports count).
*/
Tff7b72private Tff7b72fun Td2a8ffcomputeAverageMetricsTb4b4b4(Tb4b4b4)Tb4b4b4: Te6edf3PairTff7b72<Tffa657DoubleTb4b4b4, Tffa657DoubleTff7b72> Tb4b4b4{
Tff7b72val Te6edf3qualifiedEntries Tff7b72= Te6edf3deviceMetricsLogTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3filter Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3size Tff7b72>Tff7b72= Te6edf3MIN_DEVICE_METRICS_PACKETS Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3qualifiedEntriesTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tff7b72return T79c0ff0.0 Te6edf3to T79c0ff0.0

Tff7b72val Te6edf3avgChannel Tff7b72= Te6edf3qualifiedEntriesTb4b4b4.Te6edf3map Tb4b4b4{ Te6edf3entries Tff7b72-Tff7b72> Te6edf3entriesTb4b4b4.Te6edf3map Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3channelUtil Tb4b4b4}Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4) Tb4b4b4}Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4)

T8b949e// Compute Airtime Rate as (delta air_util_tx / elapsed_time_hours) to match Apple spec FR-008
Tff7b72val Te6edf3avgAirRate Tff7b72=
Te6edf3qualifiedEntries
Tb4b4b4.Te6edf3mapNotNull Tb4b4b4{ Te6edf3entries Tff7b72-Tff7b72>
Tff7b72val Te6edf3first Tff7b72= Te6edf3entriesTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3last Tff7b72= Te6edf3entriesTb4b4b4.Te6edf3lastTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3deltaAir Tff7b72= Te6edf3lastTb4b4b4.Te6edf3airUtilTx Tff7b72- Te6edf3firstTb4b4b4.Te6edf3airUtilTx
Tff7b72val Te6edf3deltaTimeMs Tff7b72= Te6edf3lastTb4b4b4.Te6edf3timestamp Tff7b72- Te6edf3firstTb4b4b4.Te6edf3timestamp
Tff7b72if Tb4b4b4(Te6edf3deltaTimeMs Tff7b72> T79c0ff0Tb4b4b4) Tb4b4b4{
Te6edf3deltaAir Tff7b72/ Tb4b4b4(Te6edf3deltaTimeMs Tff7b72/ T79c0ff3T79c0ff6T79c0ff0T79c0ff0T79c0ff0T79c0ff0T79c0ff0.0Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72null
Tb4b4b4}
Tb4b4b4}
Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4)
Tb4b4b4.Te6edf3takeIf Tb4b4b4{ Tff7b72!Tffa657itTb4b4b4.Te6edf3isNaNTb4b4b4(Tb4b4b4) Tb4b4b4} Tff7b72?: T79c0ff0.0

Tff7b72return Te6edf3avgChannel Te6edf3to Te6edf3avgAirRate
Tb4b4b4}

T8b949e/** Builds one immutable terminal snapshot. Caller must hold [mutex]. */
Tff7b72private Tff7b72fun Td2a8ffterminalRequestLockedTb4b4b4(
Te6edf3pendingStatusTb4b4b4: Tffa657StringTb4b4b4,
Te6edf3outcomeTb4b4b4: Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4,
Te6edf3awaitRestoreTb4b4b4: Tffa657BooleanTb4b4b4,
Te6edf3generateAiTb4b4b4: Tffa657BooleanTb4b4b4,
Tb4b4b4)Tb4b4b4: Te6edf3DiscoveryTerminalRequest Tb4b4b4{
Tff7b72val Te6edf3session Tff7b72= Te6edf3_currentSessionTb4b4b4.Te6edf3value
Tff7b72val Te6edf3finalStatus Tff7b72= Te6edf3finalStatusForPendingStatusTb4b4b4(Te6edf3pendingStatusTb4b4b4)
Tff7b72val Te6edf3config Tff7b72= Te6edf3sessionTff7b72?.Te6edf3homeLoraConfig Tff7b72?: Te6edf3originalLoRaConfig
Tff7b72val Te6edf3primaryChannel Tff7b72= Te6edf3sessionTff7b72?.Te6edf3homePrimaryChannel Tff7b72?: Te6edf3originalPrimaryChannel
Tff7b72if Tb4b4b4(Te6edf3tunedPrimaryChannel Tff7b72&Tff7b72& Te6edf3primaryChannel Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3e Tb4b4b4{
Ta5d6ff"Ta5d6ffDiscoveryScanEngine: primary channel was retuned without a captured home channel; Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ffrestoring LoRa config only for session Tffd700${Te6edf3sessionTff7b72?.Te6edf3id Tff7b72?: Te6edf3sessionIdTffd700}Ta5d6ff"
Tb4b4b4}
Tb4b4b4}
Tff7b72val Te6edf3restorePlan Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3session Tff7b72=Tff7b72= Tff7b72null Tff7b72|Tff7b72| Te6edf3config Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Tff7b72null
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3DiscoveryHomeRestorePlanTb4b4b4(
Te6edf3sessionId Tff7b72= Te6edf3sessionTb4b4b4.Te6edf3idTb4b4b4,
Te6edf3deviceAddress Tff7b72= Te6edf3sessionTb4b4b4.Te6edf3deviceAddressTb4b4b4,
Te6edf3loraConfig Tff7b72= Te6edf3configTb4b4b4,
Te6edf3primaryChannel Tff7b72= Te6edf3primaryChannelTb4b4b4,
Te6edf3restorePrimaryChannel Tff7b72= Te6edf3tunedPrimaryChannel Tff7b72&Tff7b72& Te6edf3primaryChannel Tff7b72!Tff7b72= Tff7b72nullTb4b4b4,
Te6edf3finalStatus Tff7b72= Te6edf3finalStatusTb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tff7b72return Te6edf3DiscoveryTerminalRequestTb4b4b4(
Te6edf3sessionId Tff7b72= Te6edf3sessionTff7b72?.Te6edf3id Tff7b72?: Te6edf3sessionIdTb4b4b4,
Te6edf3restorePlan Tff7b72= Te6edf3restorePlanTb4b4b4,
Te6edf3pendingStatus Tff7b72= Te6edf3pendingStatusTb4b4b4,
Te6edf3outcome Tff7b72= Te6edf3outcomeTb4b4b4,
Te6edf3awaitRestore Tff7b72= Te6edf3awaitRestoreTb4b4b4,
Te6edf3shouldGenerateAi Tff7b72= Te6edf3generateAiTb4b4b4,
Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8fffinalStatusForPendingStatusTb4b4b4(Te6edf3pendingStatusTb4b4b4: Tffa657StringTb4b4b4)Tb4b4b4: Tffa657String Tff7b72=
Te6edf3finalStatusForPendingRestoreTb4b4b4(Te6edf3pendingStatusTb4b4b4, Te6edf3default Tff7b72= Te6edf3DiscoverySessionStatusTb4b4b4.Te6edf3FAILEDTb4b4b4)

T8b949e// endregion

T8b949e// region Interrupted-session restoration

T8b949e/**
* Watches reconnects for persisted discovery sessions left without a confirmed home-radio restore. Recovery is
* device-bound and skipped while this engine owns an active scan.
*/
Tff7b72suspend Tff7b72fun Td2a8ffrestoreInterruptedSessionsOnReconnectTb4b4b4(Te6edf3onRestoredTb4b4b4: Te6edf3suspend Tb4b4b4(Te6edf3homePresetTb4b4b4: Tffa657StringTb4b4b4) Tff7b72-Tff7b72> Tffa657Unit Tff7b72= Tb4b4b4{Tb4b4b4}Tb4b4b4)Tb4b4b4: Tffa657Unit Tff7b72=
Te6edf3interruptedSessionRecoveryTb4b4b4.Te6edf3watchTb4b4b4(Te6edf3onRestoredTb4b4b4)

T8b949e// endregion

T8b949e// region Lifecycle helpers

Tff7b72private Tff7b72fun Td2a8ffcancelScanInternalTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3collectorRegistryTb4b4b4.Te6edf3collector Tff7b72= Tff7b72null
Te6edf3dwellJobTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3dwellJob Tff7b72= Tff7b72null
Te6edf3scanScopeTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3scanScope Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e// endregion

Tff7b72companion Tff7b72object Tb4b4b4{
Tff7b72private Tff7b72const Tff7b72val Te6edf3CONFIG_SNAPSHOT_TIMEOUT_MS Tff7b72= T79c0ff5Te6edf3_000L
Tff7b72private Tff7b72const Tff7b72val Te6edf3RECONNECT_TIMEOUT_MS Tff7b72= T79c0ff6T79c0ff0Te6edf3_000L
Tff7b72private Tff7b72const Tff7b72val Te6edf3TICK_INTERVAL_MS Tff7b72= T79c0ff1Te6edf3_000L
Tff7b72private Tff7b72const Tff7b72val Te6edf3POSITION_DIVISOR Tff7b72= T79c0ff1Te6edf3e7
Tff7b72private Tff7b72const Tff7b72val Te6edf3MIN_DEVICE_METRICS_PACKETS Tff7b72= T79c0ff2
Tff7b72private Tff7b72const Tff7b72val Te6edf3PERCENT_MULTIPLIER Tff7b72= T79c0ff1T79c0ff0T79c0ff0.0

T8b949e/** Node roles that indicate infrastructure (Router, RouterLate, ClientBase). */
Tff7b72private Tff7b72val Te6edf3INFRASTRUCTURE_ROLES Tff7b72=
Te6edf3setOfTb4b4b4(
Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3ROUTERTb4b4b4,
Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3ROUTER_LATETb4b4b4,
Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3CLIENT_BASETb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Served by rngit 1.5.0 - Generated in 0.2s